/**
 * Copyright 2021 nicos AG
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 *
 * @author Simon Petrac <spetrac@nicos-ag.com>
 * @contributor Jörg Langkau <jlangkau@nicos-ag.com>
 * @license Apache-2.0
 */

const
    util           = exports,
    /** @see https://nodejs.org/docs/latest-v16.x/api/path.html */
    path           = require('path'),
    /** @see https://nodejs.org/docs/latest-v16.x/api/fs.html */
    fs             = require('fs/promises'),
    /** @see https://rdf.js.org/N3.js/ */
    n3             = require('n3'),
    /** @see https://github.com/digitalbazaar/jsonld.js */
    jsonld         = require('jsonld'),
    /** @see https://github.com/zazuko/rdf-validate-shacl */
    SHACLValidator = require('rdf-validate-shacl');

/**
 * Wraps an asynchronous function into an immediately invoked function expression.
 * This is useful because awaiting asynchronous calls is not possible at script root level.
 * It also catches any errors and logs them to the console, because not catching them is deprecated in javascript.
 * @param {function(...any): Promise<any>} asyncFn The function to wrap and immediately invoke.
 * @param {...any} args Optional input arguments for the function.
 * @returns {Promise<any>|any} The result of the function itself if necessary for later.
 */
util.asyncIIFE = function (asyncFn, ...args) {
    const promise = asyncFn.apply(null, args);
    promise.catch?.((err) => console.error(err?.stack ?? err));
    return promise;
}; // asyncIIFE

/**
 * Wraps an array of arguments and an asynchronous map function and returns the array of results.
 * This is useful to apply an asynchronous function to a number of arguments and wait for the results or just the execution.
 * @param {Array<any>} argsArr
 * @param {function(any): Promise<any>} mapFn
 * @returns {Promise<Array<any>>}
 */
util.asyncMap = function (argsArr, mapFn) {
    return Promise.all(argsArr.map(mapFn));
}; // asyncMap

/**
 * This method returns always an array, no matter what the input is.
 * If the value is falsy, it will return an empty array, and if the value is an iterable object,
 * it convert it to an array. In all other cases if the value is not an array already,
 * it will create an array with the value as single entry.
 * @param {null|object|Array|Iterable|any} arrayLike
 * @returns {Array}
 */
util.toArray = function (arrayLike) {
    if (!arrayLike) {
        return [];
    } else if (typeof arrayLike === 'object') {
        if (Array.isArray(arrayLike)) return arrayLike;
        else if (typeof arrayLike[Symbol.iterator] === 'function') return Array.from(arrayLike);
        else return [arrayLike];
    } else {
        return [arrayLike];
    }
}; // toArray

/**
 * Descriptions often contain special markdown characters or newline characters that would not
 * be displayed as-is by a markdown renderer. This method escapes all special characters and replaces
 * newline characters with html line-breaks to be displayed correctly in a markdown file.
 * Escaped characters are the following: \`*_{}[]()#+-.!|
 * @param {string} textString A text that may contain newlines and markdown special characters.
 * @returns {string} A string that is displayed like the original text by a markdown renderer.
 */
util.textToMarkdown = function (textString) {
    if (!textString) return '';
    return textString
        .replace(/[\\`*_{}[\]()#+\-.!|]/g, (match) => '\\' + match)
        .replace(/\r?\n/g, '<br>');
}; // textToMarkdown

/**
 * Combines multiple path segments into one path and normalizes the result.
 * @param {...string} paths The first segment should be an absolute path.
 * @returns {string} The combined and normalized path from all path segments.
 */
util.joinPath = path.join;

/**
 * Reads a file from disc.
 * @param {string} filePath The absolute path to the file.
 * @returns {Promise<string>} The content of the file as string.
 */
util.readFile = async function (filePath) {
    const buffer = await fs.readFile(filePath);
    return buffer.toString();
}; // readFile

/**
 * Writes a file to disc, even if the folder does not exist.
 * @param {string} filePath The absolute path to the file.
 * @param {string} content The content to be written to the file.
 * @returns {Promise<void>}
 */
util.writeFile = async function (filePath, content) {
    // return console.log('WRITE:', filePath);
    const folderPath = path.dirname(filePath);
    await fs.mkdir(folderPath, {recursive: true});
    await fs.writeFile(filePath, content);
}; // writeFile

/**
 * Deletes a folder from the filesystem and all its contents.
 * @param {string} folderPath The absolute path to the folder.
 * @returns {Promise<void>}
 */
util.deleteFolder = async function (folderPath) {
    await fs.rmdir(folderPath, {recursive: true});
}; // deleteFolder

/**
 * Creates a named node from an iri. These nodes can be used to query data in the datasets.
 * @param {string} iri The absolute iri for this node.
 * @returns {NamedNode} An object node that works with datasets.
 */
util.namedNode = n3.DataFactory.namedNode;

/**
 * Creates a literal from a value. These nodes can be used to query data in the datasets.
 * @param {string} value The value for this literal.
 * @param {string|NamedNode} [languageOrDataType] The language of the literal, if a string, and the datatype, if a named node.
 * @returns {Literal} A literal node that works with datasets.
 */
util.literal = n3.DataFactory.literal;

/**
 * Creates a constructor for named nodes with a given namespace iri.
 * These namespaces are easier to use when constructing named nodes.
 * (Some namespaces are predefined on this method, like ns.rdf or ns.xsd)
 * @param {string} namespace The iri for the namespace which is used to prepend to all values.
 * @returns {function(value: string): NamedNode} A function returning a named node of the given namespace.
 */
util.ns = function (namespace) {
    return function (value = '') {
        return util.namedNode(namespace + value);
    };
}; // ns = namespace

// The following rows contain the predefined namespaces.
util.ns.cc       = util.ns('http://creativecommons.org/ns#');
util.ns.dct      = util.ns('http://purl.org/dc/terms/');
util.ns.foaf     = util.ns('http://xmlns.com/foaf/0.1/');
util.ns.odrl     = util.ns('http://www.w3.org/ns/odrl/2/');
util.ns.owl      = util.ns('http://www.w3.org/2002/07/owl#');
util.ns.prov     = util.ns('http://www.w3.org/ns/prov#');
util.ns.rdf      = util.ns('http://www.w3.org/1999/02/22-rdf-syntax-ns#');
util.ns.rdfs     = util.ns('http://www.w3.org/2000/01/rdf-schema#');
util.ns.sh       = util.ns('http://www.w3.org/ns/shacl#');
util.ns.vann     = util.ns('http://purl.org/vocab/vann/');
util.ns.voaf     = util.ns('http://purl.org/vocommons/voaf#');
util.ns.void     = util.ns('http://rdfs.org/ns/void#');
util.ns.xsd      = util.ns('http://www.w3.org/2001/XMLSchema#');
util.ns.ids      = util.ns('https://w3id.org/idsa/core/');
util.ns.idsa     = util.ns('https://w3id.org/idsa/');
util.ns.ids3cm   = util.ns('https://w3id.org/ids3cm/');
util.ns.ids3c_co = util.ns('https://w3id.org/ids3c-component/');
util.ns.iec      = util.ns('https://www.iecee.org/');

/**
 * This method searches through a list of literals, either in rdf/js format or json-ld format,
 * and selects the one with the matching language.
 * @param {null|Literal|object|Array<Literal|object>} literals Probably an array of literals in rdf/js or json-ld format.
 * @param {string} [language='en'] The language to select from the literals, fallback is english or the first entry.
 * @returns {string} The value of the selected literal or an empty string.
 */
util.getLangStringFromLiterals = function (literals, language = 'en') {
    const
        literalArray  = util.toArray(literals),
        languageArray = literalArray.map((literal) => literal?.language || literal?.['@language']),
        resultIndex   = ((languageArray.indexOf(language) + 1) || (languageArray.indexOf('en') + 1) || 1) - 1,
        resultLiteral = literalArray[resultIndex] || null;
    return resultLiteral?.value || resultLiteral?.['@value'] || '';
}; // getLangStringFromLiterals

/**
 * Takes a graph node and returns its id. Useful for array map function.
 * @param {{'@id': string}} node
 * @returns {string}
 */
util.nodeToId = function (node) {
    return node['@id'];
}; // nodeToId

const RE_compareSplitter = /(\d+)|\D+/g;

/**
 * This method compares two strings to use in any sorting algorithm.
 * The comparison is number aware and improves the native string comparison.
 * @param {string} valueA The first value for the comparison.
 * @param {string} valueB The second value for the comparison.
 * @returns {-1|0|1} The number tells the order: A -1 if valueA comes first and a 1 if valueB comes first.
 */
util.compareStrings = function (valueA, valueB) {
    if (valueA === valueB) return 0;

    const
        arrA   = Array.from(valueA.matchAll(RE_compareSplitter)),
        arrB   = Array.from(valueB.matchAll(RE_compareSplitter)),
        minLen = Math.min(arrA.length, arrB.length);

    for (let i = 0; i < minLen; i++) {
        const
            [strA, numA] = arrA[i],
            [strB, numB] = arrB[i];

        if (strA === strB) continue;
        if (numA && numB) return Number(numA) - Number(numB);
        return strA < strB ? -1 : 1;
    }

    return arrA.length - arrB.length;
}; // compareStrings

/**
 * Sorting an array of nodes is really common. This method can be used as a sorting function.
 * It uses the string comparison method from above to compare their value or id.
 * @param {NamedNode | {'@id': string}} nodeA The first node for the comparison.
 * @param {NamedNode | {'@id': string}} nodeB The second node for the comparison.
 * @returns {-1|0|1} The number tells the order: A -1 if nodeA comes first and a 1 if nodeB comes first.
 */
util.compareNodes = function (nodeA, nodeB) {
    const valueA = nodeA.value || nodeA['@id'] || '';
    const valueB = nodeB.value || nodeB['@id'] || '';
    return util.compareStrings(valueA, valueB);
}; // compareNodes

/**
 * This class is a convenience wrapper for the N3Store.
 * It tries to comply with rdf/js specification for datasets.
 * It also adds the context attribute, which holds the prefixes from reading turtle files.
 * @see https://rdf.js.org/dataset-spec/
 */
class Dataset extends n3.Store {
    constructor(quads, context) {
        super(quads || undefined);
        Object.defineProperty(this, 'context', {
            value: context || {}
        });
    }

    match(...args) {
        return new Dataset(this.getQuads(...args), this.context);
    }

    has(...args) {
        return this.countQuads(...args) > 0;
    }

    add(...args) {
        this.addQuad(...args);
    }

    delete(...args) {
        this.removeQuad(...args);
    }
} // Dataset

const
    rdf_first = util.ns.rdf('first'),
    rdf_rest  = util.ns.rdf('rest'),
    rdf_nil   = util.ns.rdf('nil');

/**
 * If an array of term nodes could contain list nodes that should also be collected,
 * this method can be used to search in the dataset and return all nodes as one array.
 * @param {Dataset} dataset The dataset were the node array came from.
 * @param {NamedNode|BlankNode|Array<NamedNode|BlankNode>} nodeArr A node array from a previous query, e.g. getObjects.
 * @returns {Array<NamedNode|BlankNode>} All nodes and descending list entries flat in one array.
 */
util.collectArrayFromListNodes = function (dataset, nodeArr) {
    return util.toArray(nodeArr).map((node) => {
        if (rdf_nil.equals(node)) return [];

        let [first] = dataset.getObjects(node, rdf_first);
        if (!first) return node;

        let listArr = [], next = node, rest;
        while (next && !rdf_nil.equals(next)) {
            [first] = dataset.getObjects(next, rdf_first);
            [rest]  = dataset.getObjects(next, rdf_rest);
            if (first) listArr.push(first);
            next = rest;
        }

        return listArr;
    }).flat(1);
}; // collectArrayFromListNodes

/**
 *
 * @param {string|Array<string>} fileOrFolderArray The absolute paths to files or folders containing wanted files.
 * @param {RegExp} [fileRegExp] An optional regular expression to filter file paths against.
 * @returns {Promise<Array<string>>} The absolute paths to all directly or indirectly specified files.
 */
util.expandFoldersToFiles = async function (fileOrFolderArray, fileRegExp) {
    if (!Array.isArray(fileOrFolderArray)) fileOrFolderArray = [fileOrFolderArray];
    const files = new Set();
    await Promise.all(fileOrFolderArray.map(async (fileOrFolder) => {
        try {
            const dir = await fs.opendir(fileOrFolder);
            for await (const entry of dir) {
                const filePath = path.join(fileOrFolder, entry.name);
                if (entry.isFile() && (!fileRegExp || fileRegExp.test(filePath))) {
                    files.add(filePath);
                }
            }
        } catch (err) {
            if (!fileRegExp || fileRegExp.test(fileOrFolder)) {
                files.add(fileOrFolder);
            }
        }
    }));
    return Array.from(files.values());
}; // expandFoldersToFiles

/**
 * Loads turtle files into a dataset to manage the RDF triples.
 * @param {string|Array<string>} fileArray The absolute paths to all files, that should be loaded into the dataset.
 * @returns {Promise<Dataset>} The dataset containing the triples from the turtle files.
 */
util.loadDataFromTurtleFiles = async function (fileArray) {
    if (!Array.isArray(fileArray)) fileArray = [fileArray];
    const data = new Dataset();
    await Promise.all(fileArray.map(async (filePath) => {
        const fileName = path.basename(filePath);
        const buffer   = await fs.readFile(path.normalize(filePath));
        const parser   = new n3.Parser();
        const prefixes = await new Promise((resolve, reject) => {
            parser.parse(buffer.toString(), function _handleData(error, quad, prefixes) {
                if (error) {
                    const [match, lineNumber] = (error.message || '').match(/on line (\d+)/) || [0, 1];
                    reject(new Error(`${error.message}\n    at ${fileName} (${filePath}:${lineNumber}:1)`));
                } else if (quad) {
                    data.addQuad(quad);
                } else {
                    resolve(prefixes);
                }
            });
        });
        for (let [prefix, prefixURI] of Object.entries(prefixes)) {
            const currentURI = data.context[prefix];
            if (currentURI && prefixURI !== currentURI)
                throw new Error(`Not matching prefix (${prefix}) for ${fileName} with the previously loaded files.`);
        }
        if (prefixes) Object.assign(data.context, prefixes);
    }));
    return data;
}; // loadDataFromTurtleFiles

/**
 * Compares a dataset with data against a dataset with shapes to generate a validation dataset.
 * @param {Dataset} data The dataset with the triples that needs validating.
 * @param {Dataset} shapes The dataset with the triples defining shacl shapes.
 * @returns {Promise<Dataset>} The dataset with the shacl validation result.
 */
util.validateDataAgainstShapes = async function (data, shapes) {
    const validator = new SHACLValidator(shapes);
    const report    = await validator.validate(data);
    const result    = new Dataset();
    Object.assign(result.context, data.context, shapes.context);
    for (let quad of report.dataset) {
        result.addQuad(quad);
    }
    return result;
}; // validateDataAgainstShapes

/**
 * Saves a dataset as turtle format to the disc.
 * @param {string} filePath The file path for the resulting turtle file. Any existing file will be overridden.
 * @param {Dataset} data The dataset with the triples to save. Loaded prefixes should be preserved.
 * @returns {Promise<void>} Void if everything goes well.
 */
util.saveDataToTurtleFile = async function (filePath, data) {
    const writer = new n3.Writer({prefixes: data.context});
    for (let quad of data) {
        writer.addQuad(quad);
    }
    const turtle     = await new Promise((resolve, reject) => writer.end(
        (err, result) => err ? reject(err) : resolve(result)
    ));
    const prettified = turtle
        .replace(/;(?=\n)/g, ' ;')
        .replace(/\.(?=\n)/g, ' .')
        .replace(/\.(?=\n[\w<])/ig, '.\n');
    await util.writeFile(filePath, prettified);
}; // saveDataToTurtleFile

/**
 * Saves a dataset as jsonld format to the disc.
 * @param {string} filePath The file path for the resulting jsonld file. Any existing file will be overridden.
 * @param {Dataset} data The dataset with the triples to save. Loaded prefixes should be preserved.
 * @returns {Promise<void>} Void if everything goes well.
 */
util.saveDataToJsonLdFile = async function (filePath, data) {
    const jsonDoc    = await jsonld.fromRDF(data);
    const compactDoc = await jsonld.compact(jsonDoc, data.context);
    const jsonStr    = JSON.stringify(compactDoc, null, 2);
    await util.writeFile(filePath, jsonStr);
}; // saveDataToJsonLdFile

/**
 * Generates a connected json graph from a dataset.
 * Also decorates the resulting Map object with some convenience methods to query nodes.
 * @param {Dataset} data The dataset with the triples to generate the graph from.
 * @returns {Promise<Map<string, object>>} The json graph as a map of objects.
 */
util.generateGraphFromData = async function (data) {
    const writer = new n3.Writer({prefixes: data.context, format: 'N-Quads'});
    for (let quad of data) {
        writer.addQuad(quad);
    }
    // REM this hack with converting the data to n-quads and back to json-ld
    // is only necessary, because the jsonld module has a bug with rdf:lists
    // SEE https://github.com/digitalbazaar/jsonld.js/issues/280
    const n_quads_doc = await new Promise((resolve, reject) => writer.end(
        (err, result) => err ? reject(err) : resolve(result)
    ));
    const jsonDoc     = await jsonld.fromRDF(n_quads_doc, {format: 'application/n-quads'});
    //const jsonDoc    = await jsonld.fromRDF(data);
    const compactDoc  = await jsonld.compact(jsonDoc, data.context);
    //debugger;
    //const graph = new Map(compactDoc['@graph'].map((node) => [node['@id'], node]));
    const graph = new Map((compactDoc['@graph'] || [compactDoc]).map((node) => [node['@id'], node]));
    for (let node of graph.values()) {
        for (let [key, value] of Object.entries(node)) {
            if (value['@list']) {
                for (let index = 0; index < value['@list'].length; index++) {
                    if (value['@list'][index]?.['@id']) {
                        value['@list'][index] = graph.get(value['@list'][index]['@id']) || value[index];
                        //debugger;
                    } // if ()
                } // for(index)
            } else {
                if (Array.isArray(value)) {
                    for (let index = 0; index < value.length; index++) {
                        if (value[index]?.['@id']) {
                            value[index] = graph.get(value[index]['@id']) || value[index];
                        }
                    }
                } else if (value?.['@id']) {
                    node[key] = (graph.get(value['@id']) || value);
                } // if()
            } // if ()
        } // for(key)
    } // for (node)

    Object.defineProperties(graph, {
        filter:         {
            /**
             * Use this method to filter the nodes of the graph.
             * @param {function (node: object, id: string, graph: Map): boolean} callback The filter method that is called for each node.
             * @returns {Array<object>} Array containing the filtered nodes.
             */
            value: function (callback) {
                const result = [];
                for (let [key, value] of this.entries()) {
                    const valid = callback(value, key, this);
                    if (valid) result.push(value);
                }
                return result;
            }
        },
        getNodesByType: {
            /**
             * Use this method to get nodes of specific types.
             * @param {...string} types All types that should apply to the node.
             * @returns {Array<object>} Array containing the filtered nodes.
             */
            value: function (...types) {
                return this.filter((node) => {
                    const nodeTypes = util.toArray(node['@type']);
                    return types.every(type => nodeTypes.includes(type));
                });
            }
        }
    });

    return graph;
}; // generateGraphFromData
